● Zion Boggan
repos/Sightline/frontend/src/app/investigations/[id]/page.tsx
zionboggan.com ↗
110 lines · tsx
History for this file →
1
"use client";
2
import { useEffect, useRef, useState } from "react";
3
import { useParams } from "next/navigation";
4
import Link from "next/link";
5
import { api, downloadAuthed, EvidenceItem, Investigation, TimelineEntry } from "@/lib/api";
6
 
7
export default function InvestigationDetail() {
8
  const { id } = useParams<{ id: string }>();
9
  const [inv, setInv] = useState<Investigation | null>(null);
10
  const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
11
  const [evidence, setEvidence] = useState<EvidenceItem[]>([]);
12
  const [report, setReport] = useState<any | null>(null);
13
  const [reportId, setReportId] = useState<string | null>(null);
14
  const [err, setErr] = useState<string | null>(null);
15
  const fileRef = useRef<HTMLInputElement>(null);
16
 
17
  function loadEvidence() { api.evidenceList(id).then(setEvidence).catch(() => {}); }
18
 
19
  useEffect(() => {
20
    Promise.all([api.investigation(id), api.timeline(id)])
21
      .then(([i, t]) => { setInv(i); setTimeline(t); })
22
      .catch((e) => setErr(e.status === 401 ? "Sign in to view this investigation." : String(e)));
23
    loadEvidence();
24
  }, [id]);
25
 
26
  async function genReport() {
27
    try { const r = await api.createReport(id); setReport(r.report); setReportId(r.report_id); }
28
    catch { setErr("Report generation failed."); }
29
  }
30
 
31
  async function uploadEvidence(file: File) {
32
    try { await api.uploadEvidence(id, file); loadEvidence(); }
33
    catch (e: any) { setErr(e.status === 403 ? "Your role cannot attach evidence." : "Evidence upload failed."); }
34
  }
35
 
36
  if (err) return <div className="badge danger">{err}</div>;
37
  if (!inv) return <div className="muted">Loading…</div>;
38
 
39
  return (
40
    <div>
41
      <h1>{inv.title}</h1>
42
      <div className="row"><span className="badge">{inv.status}</span><span className="badge">{inv.priority}</span></div>
43
      <p className="muted">{inv.summary}</p>
44
 
45
      <div className="row" style={{ justifyContent: "space-between" }}>
46
        <h2 style={{ margin: 0 }}>Sighting timeline</h2>
47
        <button className="primary" onClick={genReport}>Generate report</button>
48
      </div>
49
      <div className="card" style={{ marginTop: 10 }}>
50
        <table>
51
          <thead><tr><th>Time</th><th>Plate</th><th>Camera</th><th>Dir</th><th>Conf</th></tr></thead>
52
          <tbody>
53
            {timeline.map((t, i) => (
54
              <tr key={i}>
55
                <td>{new Date(t.read_ts).toLocaleString()}</td>
56
                <td><Link href={`/vehicles/${t.plate_norm}`}>{t.plate_norm}</Link></td>
57
                <td className="muted">{t.camera_id?.slice(0, 8)}</td>
58
                <td>{t.direction || "-"}</td>
59
                <td>{t.confidence != null ? (t.confidence * 100).toFixed(0) + "%" : "-"}</td>
60
              </tr>
61
            ))}
62
            {!timeline.length && <tr><td colSpan={5} className="muted">No sightings attached.</td></tr>}
63
          </tbody>
64
        </table>
65
      </div>
66
 
67
      <h2>Evidence</h2>
68
      <div className="card">
69
        <div className="row" style={{ marginBottom: 10 }}>
70
          <input ref={fileRef} type="file" hidden onChange={(e) => e.target.files?.[0] && uploadEvidence(e.target.files[0])} />
71
          <button onClick={() => fileRef.current?.click()}>+ Attach evidence file</button>
72
          <span className="muted">SHA-256 hashed on upload; integrity verified on download.</span>
73
        </div>
74
        <table>
75
          <thead><tr><th>File</th><th>Size</th><th>SHA-256</th><th></th></tr></thead>
76
          <tbody>
77
            {evidence.map((e) => (
78
              <tr key={e.id}>
79
                <td><Link href={`/evidence/${e.id}`}>{e.filename}</Link></td>
80
                <td className="muted">{(e.size_bytes / 1024).toFixed(1)} KB</td>
81
                <td className="muted" style={{ fontFamily: "monospace", fontSize: 11 }}>{e.sha256.slice(0, 16)}…</td>
82
                <td><a style={{ cursor: "pointer" }} onClick={() => downloadAuthed(`/evidence/${e.id}/download`, e.filename)}>download</a></td>
83
              </tr>
84
            ))}
85
            {!evidence.length && <tr><td colSpan={4} className="muted">No evidence attached.</td></tr>}
86
          </tbody>
87
        </table>
88
      </div>
89
 
90
      {report && reportId && (
91
        <>
92
          <h2>Report</h2>
93
          <div className="card">
94
            <p>{report.sections?.executive_summary}</p>
95
            <strong>Key findings</strong>
96
            <ul>{(report.sections?.key_findings || []).map((f: string, i: number) => <li key={i}>{f}</li>)}</ul>
97
            <div className="row" style={{ marginTop: 8 }}>
98
              <span className="muted">Download:</span>
99
              {["markdown", "json", "csv", "pdf"].map((fmt) => (
100
                <a key={fmt} style={{ cursor: "pointer" }}
101
                   onClick={() => downloadAuthed(`/reports/${reportId}/download?format=${fmt}`,
102
                     `report.${fmt === "markdown" ? "md" : fmt}`)}>{fmt}</a>
103
              ))}
104
            </div>
105
          </div>
106
        </>
107
      )}
108
    </div>
109
  );
110
}